package com.demo.topics.util;

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLConnection;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

import android.app.Activity;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.os.Environment;
import android.text.TextUtils;
import android.util.Base64;
import android.util.Log;

/**
 * This class wraps a common pattern: fetching many images in a background
 * thread pool for Android applications and caching them for future use.
 * <p/>
 * A very common requirement in Android applications is the need to create, for
 * instance, a ListView containing lots of images which are loaded from remote
 * URLs. Often times, these images are not expected to change, and it would be
 * most useful to cache the images on local disk such that on future fetches of
 * the URL, the data is fetched locally from disk cache rather than making an
 * expensive and slow network call. This class wraps:
 * <p/>
 * <ul>
 * <li>Creating a thread pool to manage outstanding requests so we don't thread
 * bomb ourselves</li>
 * <li>Passing URLs to fetch, executing a callback when the URL has been
 * fetched. The fetcher instance then checks for the presence of the data in the
 * cache. If it exists, the fetcher returns the cached data. If it does not
 * exist, the fetcher fetches the data in a background thread, saves it to disk,
 * then executes the completion callback.</li>
 * </ul>
 * <p/>
 * Here's how this class might be used in a custom implementation of
 * ArrayAdapter
 * <p/>
 * <pre>
 * private static class MyCustomAdapter extends ArrayAdapter&lt;MyCustomClass&gt; {
 * 	private Activity mContext;
 * 	private int mLayoutResourceId;
 *
 * 	// You don't need many instances of this class
 * 	private ImageFetcher mFetcher;
 *
 * 	public MyCustomAdapter(Context context, int textViewResourceId) {
 * 		super(context, textViewResourceId);
 * 		mContext = (Activity) context;
 * 		mLayoutResourceId = textViewResourceId;
 *
 * 		mFetcher = new ImageFetcher(mContext);
 *     }
 *
 * 	&#064;Override
 * 	public View getView(int position, View convertView, ViewGroup parent) {
 * 		View rowView = convertView;
 * 		if (rowView == null) {
 * 			LayoutInflater inflater = mContext.getLayoutInflater();
 * 			rowView = inflater.inflate(mLayoutResourceId, null);
 *         }
 *
 * 		// Make sure we are in bounds and draw the item
 * 		if (position &lt; getCount()) {
 * 			MyCustomClass myData = getItem(position);
 *
 * 			// Add an image fetch job to the queue
 * 			// Assumes presence of an ImageView with ID thumbnail
 * 			final ImageView thumbnail = (ImageView) rowView
 * 					.findViewById(R.id.thumbnail);
 *
 * 			ImageFetcher.ImageFetchJob job = new ImageFetcher.ImageFetchJob(
 * 					myData.getThumbnailUrl());
 * 			job.setOnFetchCompleteListener(new ImageFetcher.OnFetchCompleteListener() {
 *
 * 				&#064;Override
 * 				public void onFetchComplete(Bitmap result) {
 * 					thumbnail.setImageBitmap(result);
 *                 }
 *             });
 *
 * 			mFetcher.enqueue(job);
 *         }
 * 		return rowView;
 *     }
 * }
 * </pre>
 *
 * @author Ikai Lan <ikai@google.com>
 */
public class ImageFetcher {
    private static final String TAG = ImageFetcher.class.getSimpleName();

    public final static int DEFAULT_THREAD_POOL_SIZE = 3;

    private Context mContext;
    private ExecutorService mExecutor;

    /**
     * Interface for implementing a callback to be executed on the UI thread
     * when the data has been fetched either from the remote server or the local
     * cache.
     *
     * @author Ikai Lan <ikai@google.com>
     */
    public static interface OnFetchCompleteListener {
        public void onFetchComplete(Bitmap result);
    }

    /**
     * Dummy class to execute when we have completed the job. This is the
     * default listener.
     *
     * @author Ikai Lan <ikai@google.com>
     */
    private static class DefaultFetchCompleteListener implements
            OnFetchCompleteListener {
        @Override
        public void onFetchComplete(Bitmap result) {
            Log.d(TAG,
                    "Fetch completed. Override this if fetch should do something.");
        }

    }

    /**
     * This class represents a job to fetch some data and cache it locally.
     * Invokes an instance of OnFetchCompleteListener when completed.
     */
    public static class ImageFetchJob {
        private final String mUrl;
        private OnFetchCompleteListener mCallback;

        /**
         * @param url the url to fetch
         */
        public ImageFetchJob(String url) {
            mUrl = url;
            mCallback = new DefaultFetchCompleteListener();
        }

        /**
         * @return the URL this job is responsible for fetching
         */
        public String getUrl() {
            return mUrl;
        }

        /**
         * Sets a callback to be executed when the data either has been fetched
         * from the remote server or from the cache. The callback is executed on
         * the UI thread.
         *
         * @param callback a callback to be executed on the UI thread
         */
        public void setOnFetchCompleteListener(OnFetchCompleteListener callback) {
            mCallback = callback;
        }

        /**
         * @return the callback to be executed when this job has completed
         */
        public OnFetchCompleteListener getOnFetchCompleteListener() {
            return mCallback;
        }
    }

    /**
     * Creates a new ImageFetcher. Uses a thread pool size of
     * {@link ImageFetcher#DEFAULT_THREAD_POOL_SIZE}.
     *
     * @param context the calling {@link Context}
     */
    public ImageFetcher(Context context) {
        this(context, DEFAULT_THREAD_POOL_SIZE);
    }

    /**
     * Constructor that allows us to specific a thread pool size.
     *
     * @param context        a calling context to provide a UI thread
     * @param threadPoolSize a thread pool size to use
     */
    public ImageFetcher(Context context, int threadPoolSize) {
        mContext = context;
        mExecutor = Executors.newFixedThreadPool(threadPoolSize);

    }

    /**
     * Adds an ImageFetchJob to the queue. If the data has already been fetched
     * and exists in our local cache, execute the onFetchCompleteListener
     * callback immediately. Otherwise, queue it to be run on the UI thread.
     *
     * @param job the {@link ImageFetchJob} to enqueue
     */
    public void enqueue(final ImageFetchJob job) {
        if (!TextUtils.isEmpty(job.getUrl())) {
            final Bitmap cachedBitmap = getCachedBitmap(job.getUrl());
            if (cachedBitmap != null) {
                Runnable callback = new Runnable() {
                    public void run() {
                        job.getOnFetchCompleteListener().onFetchComplete(
                                cachedBitmap);
                    }
                };
                ((Activity) mContext).runOnUiThread(callback);
            } else {
                run(job);
            }
        }
    }

    /**
     * In the thread poll we have instantiated, queue or run the
     * {@link ImageFetchJob}. We will want to write the response to the cache
     * for future calls of {@link #queueFetchJob(ImageFetchJob)} to simplly
     * return the data.
     *
     * @param job a job to run on the thread pool
     */
    private void run(final ImageFetchJob job) {
        mExecutor.execute(new Runnable() {
            public void run() {
                File cacheFile = cacheFileFromUrl(job.getUrl());

                try {
                    final byte[] respBytes = readDataFromNetwork(job.getUrl());

                    // Write response bytes to cache.
                    if (cacheFile != null) {
                        try {
                            cacheFile.getParentFile().mkdirs();
                            cacheFile.createNewFile();
                            FileOutputStream fos = new FileOutputStream(
                                    cacheFile);
                            fos.write(respBytes);
                            fos.close();
                        } catch (FileNotFoundException e) {
                            Log.w(TAG, "Error writing to bitmap cache: "
                                    + cacheFile.toString(), e);
                        } catch (IOException e) {
                            Log.w(TAG, "Error writing to bitmap cache: "
                                    + cacheFile.toString(), e);
                        }
                    }

                    // Decode the bytes and return the bitmap.
                    final Bitmap bitmap = BitmapFactory.decodeByteArray(
                            respBytes, 0, respBytes.length);

                    // Create a runnable instance to be run on the UI thread
                    Runnable callback = new Runnable() {
                        public void run() {
                            job.getOnFetchCompleteListener().onFetchComplete(
                                    bitmap);
                        }
                    };
                    ((Activity) mContext).runOnUiThread(callback);

                } catch (Exception e) {
                    Log.w(TAG, "Problem while loading data: " + e.toString(), e);
                }
            }
        });
    }

    /**
     * Given a URL for an Image, return a File representing where on our local
     * disk the cached version should be saved.
     *
     * @param url to generate a file from
     * @return a {@link File} instance representing the cached image
     */
    private File cacheFileFromUrl(String url) {
        // First compute the cache key and cache file path for this URL
        File cacheFile = null;
        String cacheKey;

        try {
            cacheKey = sha1Hash(url);
        } catch (NoSuchAlgorithmException e) {
            Log.w(TAG, "SHA-1 digest not available, falling back to Base64");
            cacheKey = Base64.encodeToString(url.getBytes(), Base64.URL_SAFE);
        }

        if (Environment.MEDIA_MOUNTED.equals(Environment
                .getExternalStorageState())) {
            cacheFile = new File(Environment.getExternalStorageDirectory()
                    + File.separator + "Android" + File.separator + "data"
                    + File.separator + mContext.getPackageName()
                    + File.separator + "cache" + File.separator + "bitmap_"
                    + cacheKey + ".tmp");
        }
        return cacheFile;
    }

    /**
     * Given a url, calculates the corresponding filename. If it exists in our
     * cache, return it. If it does not, return null so we know we need to fetch
     * it from the remote server.
     *
     * @param url the url to see if we have cached or not
     * @return the cached Bitmap, or null on a cache miss
     */
    private Bitmap getCachedBitmap(String url) {
        File cacheFile = cacheFileFromUrl(url);

        // TODO: Make this clear the cache periodically
        if (cacheFile != null && cacheFile.exists()) {
            Bitmap cachedBitmap = BitmapFactory
                    .decodeFile(cacheFile.toString());
            if (cachedBitmap != null) {
                return cachedBitmap;
            }
        }
        return null;
    }

    /**
     * Given a url, fetches the data and returns the data as a byte array.
     *
     * @param url The URL to read data from
     * @return a byte array representing data read from the network
     */
    public static byte[] readDataFromNetwork(String urlString) {
        InputStream is = null;
        URL url;
        byte[] empty = new byte[]{};

        try {
            url = new URL(urlString);
        } catch (MalformedURLException e) {
            // This is the worst exception in the world
            Log.e(TAG, "Somehow received a malformed URL: " + urlString, e);
            return empty;
        }

        try {

            URLConnection conn = url.openConnection();
            conn.connect();
            is = conn.getInputStream();

            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            byte[] buffer = new byte[1024];
            int read = 0;
            while ((read = is.read(buffer, 0, buffer.length)) != -1) {
                baos.write(buffer, 0, read);
            }
            baos.flush();
            return baos.toByteArray();
        } catch (MalformedURLException e) {
            Log.e(TAG, "Bad URL", e);
        } catch (IOException e) {
            Log.e(TAG, "Could not fetch data from URL: " + url.toString(), e);
        } finally {
            try {
                if (is != null)
                    is.close();
            } catch (IOException e) {
                Log.w(TAG, "Error closing stream.");
            }
        }
        return empty;
    }

    // Helper methods

    /**
     * Returns a hex based String SHA-1 hash of the input message.
     *
     * @param message a String to hash
     * @return a String representing input String's SHA1 hash
     * @throws NoSuchAlgorithmException when the local system can't do SHA-1 digests
     */
    private static String sha1Hash(String message)
            throws NoSuchAlgorithmException {
        MessageDigest mDigest = null;

        mDigest = MessageDigest.getInstance("SHA-1");
        mDigest.update(message.getBytes());
        return bytesToHexString(mDigest.digest());
    }

    /**
     * Converts a byte[] into a hex string. This is useful for generating SHA1
     * hashes into a format that's more portable and useful in places that
     * expect simply strings, such as filenames and URLs.
     *
     * @param bytes a byte array to convert into a string
     * @return a hex string that's easier to store and pass around
     */
    private static String bytesToHexString(byte[] bytes) {
        // Source: http://stackoverflow.com/questions/332079
        StringBuffer sb = new StringBuffer();
        for (int i = 0; i < bytes.length; i++) {
            String hex = Integer.toHexString(0xFF & bytes[i]);
            if (hex.length() == 1) {
                sb.append('0');
            }
            sb.append(hex);
        }
        return sb.toString();
    }
}
